Full-Stack System Design, Frontend-Leaning

Field guide for whiteboard rounds that are ~2/3 frontend component design, ~1/3 backend. Companion to the L5 backend guide.

Case study

Plaid Link β€” Frontend Systems Design (RADIO)

Key numbers card

ThingNumber
Feels instant / feels sluggish / feels broken<100ms / >300ms / >1s (show progress past 1s)
Frame budget at 60fps16ms per frame (all JS + layout + paint)
Core Web Vitals "good"LCP <2.5s Β· INP <200ms Β· CLS <0.1
Search debounce200-300ms
Autosave debounce1-2s after last keystroke
DOM nodes before a list needs virtualization~500-1,000 rows
Fetch-all is fine up to roughly~1-5K rows / low MBs of JSON
JS bundle budget (initial, gzipped)~200KB is respectable; every 100KB β‰ˆ 100-300ms parse on mid phones
localStorage limit~5MB, synchronous (never large data)
Upload chunk size5-10MB per part (S3 multipart minimum 5MB)
Polling interval that won't hurt anyone10-30s with jitter; below ~5s consider SSE/WebSocket
staleTime for reporting-style dataminutes, not seconds (my prod default: 30min)
Access token / refresh token lifetime~15min / days, refresh in httpOnly cookie
Image formats by weightAVIF < WebP < JPEG; hero image ~100-200KB

Part 1: The 2-page guide

The round you're passing

Format: whiteboard, no coding. Roughly one third backend, two thirds frontend component design. The backend is often given to you as boilerplate ("here are the endpoints"), and your job is to design the frontend around it. You produce a general architecture, boxes and arrows, with emphasis on frontend components, then go deep on specific technology choices (why this framework, how to structure the API) and call out performance, scalability, and security issues yourself, before being asked.

What's actually graded, per the recruiter: trade-offs, thinking out loud, planning ahead, and driving the interview. It is explicitly not a checklist. A candidate who covers eight topics shallowly loses to one who makes four decisions with visible reasoning and adjusts when the interviewer pushes. Every section of this guide ends in a decision sentence for that reason, the unit of value in this round is "I chose X over Y because Z."

What to study (priority order)

PriorityTopicWhy
1Component decomposition + state placement (Ch 2-3)The 2/3 of the round. Every question starts here.
1Data fetching: query cache, pagination vs fetch-all, optimistic updates (Ch 4)Your strongest real experience; every dashboard/table question lives here.
1API design around a given backend, error/loading contracts (Ch 9)The Plaid format literally hands you a backend.
2Performance: CWV, virtualization, code splitting (Ch 5-6)You're expected to raise these unprompted.
2Security: XSS, CSRF, token storage, PII (Ch 11)Fintech interviewer; weak answers here are disqualifying.
3Real-time, forms/flows, uploads/CDN (Ch 7-8, 10, 12)Differentiators; also covers the "design Netflix" curveball.

Study method: read a worked example (Ch 18-20), then re-derive it on paper from just the prompt. If you can reproduce the boxes and the decision sentences without looking, you're ready. Don't memorize the prose.

How to approach any question (45 min)

  1. Requirements, 5 min. Who is the user, what are the 2-3 core flows, what scale (rows per user, users, read vs write), what devices, what freshness. For FE questions add: does it need to work embedded / offline / on mobile web? Write the list on the board, it's your contract for the rest of the hour.
  2. Read or sketch the API, 5 min. If the backend is given, read it aloud and extract: entities, pagination style, error shape, auth. Say what's missing ("I don't see a batch endpoint, I'll flag where I need one"). If it's not given, sketch 3-5 endpoints and move on.
  3. Boxes and arrows, 10 min. Draw the frontend skeleton (Ch 1): component tree on the left, state/data layer in the middle, API on the right. Name the 4-6 major components. This diagram is your table of contents, you'll spend the rest of the interview zooming into its boxes.
  4. Data flow deep dive, 10 min. Pick the hardest flow (usually the big list or the mutation) and walk it end to end: cache key, loading states, pagination, invalidation. This is where your production stories become answers.
  5. The three passes, 10 min. Performance pass (what's slow, what would I measure), scalability pass (10x rows, 10x users, what breaks first), security pass (XSS/CSRF/tokens/PII). Announce each pass by name, this is the "planning ahead" signal.
  6. Wrap, 5 min. Restate the 2-3 decisions you'd revisit with more time and what you'd measure post-launch.
Opening move, verbatim

"Before I draw anything, let me pin down the core flows and the data scale, the right frontend architecture is completely different for 200 rows vs 200 thousand. Then I'll read through the API you've given me, sketch the component and state architecture, deep-dive the riskiest flow, and finish with explicit performance, scaling, and security passes. Sound good?"

That one paragraph does four things: shows a plan (planning ahead), sets you up to drive, gives the interviewer a place to redirect early (taking feedback), and buys you thinking time.

Driving the interview & taking feedback

Chapter 1: The frontend skeleton

The diagram you start from

Backend rounds have a default skeleton; so do frontend rounds. Learn it cold so the first boxes cost zero thought.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ Browser ──────────────────────────┐
β”‚                                                             β”‚
β”‚  Component tree                State layer                  β”‚
β”‚  β”Œβ”€ App shell (routing, auth) ─┐                            β”‚
β”‚  β”‚  β”œβ”€ Page: Dashboard         β”‚   UI state (local/context) β”‚
β”‚  β”‚  β”‚   β”œβ”€ FilterBar           β”‚   β”œ selection, modals,     β”‚
β”‚  β”‚  β”‚   β”œβ”€ SummaryCards        β”‚   β”‚ form drafts            β”‚
β”‚  β”‚  β”‚   └─ TransactionsTable   β”‚                            β”‚
β”‚  β”‚  β”‚       β”œβ”€ Row (virtualized)   Server cache (query lib) β”‚
β”‚  β”‚  β”‚       └─ Pagination      β”‚   β”œ keyed by request       β”‚
β”‚  β”‚  └─ Page: Detail            β”‚   β”œ staleTime / gc         β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”” invalidation           β”‚
β”‚                                                             β”‚
β”‚  Data layer: query/mutation hooks Β· API client (fetch,      β”‚
β”‚  auth header, retries, error normalization)                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                β”‚ HTTPS (JSON)                 static assets ← CDN
                β–Ό
        API / BFF layer  β†’  services / DB (often given as boilerplate)

What each layer owns, in one sentence each

Decision sentence

"I split state into UI state and server cache because they have opposite lifecycles: server data is shared, refetchable, and staleness-managed; UI state is private and dies with the view. Most frontend spaghetti comes from putting both in one store."

Chapter 2: Component design

Decomposing a mock into a tree

Given a mock (or asked to imagine one), decompose by responsibility, not by visual region. A visual region ("the top area") is not a component; "the thing that owns filter state and emits filter-change events" is. Practical procedure:

  1. Circle every piece of the UI that renders one entity (a transaction row, an account card). Those are your leaf components.
  2. Circle every piece that renders a collection plus its chrome (table + pagination + empty state). Those are your container components.
  3. Whatever coordinates two containers (filters affecting both a chart and a table) belongs to their common parent, that's where shared state lives (see Ch 3).

Name components on the whiteboard with their data dependency next to them: TransactionsTable(accountId, filters). This makes the later data-flow discussion free, the arrows already exist.

Component APIs: designing props like you design REST

Interviewers at product companies grade component API design like backend interviewers grade endpoint design. The same virtues apply: minimal surface, hard to misuse, evolvable.

Controlled vs uncontrolled

Controlled: parent owns the value, child renders it and emits changes. Needed whenever anything else must react to the value while typing (live filter, cross-field validation, character counter). Cost: re-render per keystroke, parent complexity.

Uncontrolled: child owns its value internally (or the DOM does); parent reads it on submit. Cheaper and simpler; right default for plain forms.

Decision sentence

"I make inputs uncontrolled by default and promote them to controlled only when another component needs the value live. Controlled-everything is the common over-engineering; it buys re-renders per keystroke for reactivity nobody asked for."

Accessibility, the five things to say unprompted

Chapter 3: State management

The four kinds of state

KindExamplesLives inLifecycle
Server cachetransactions, accounts, user profileQuery library cacheRefetchable, shared, staleness-managed
UI stateopen modal, selected rows, active tabComponent state / small contextDies with the view
Form statedraft input, dirty flags, field errorsForm lib or local stateDies on submit/cancel
URL statecurrent page, filters, sort, selected idThe URLSurvives refresh, shareable, back-button works

The classification is the answer to half of all state questions. "Should filters go in Redux?" No: filters that describe what you're looking at belong in the URL (shareable, refresh-safe, back-button); the data they select belongs in the server cache; neither needs a global store.

Where state lives: the lifting algorithm

  1. Start with state in the component that uses it.
  2. Two siblings need it β†’ lift to the common parent, pass down.
  3. The parent chain gets silly (prop drilling through 4+ layers) β†’ context, scoped as small as possible (a TableSelectionContext, not an AppContext).
  4. Genuinely app-global and frequently changing from many places β†’ then, and only then, a store (Redux/Zustand).

Say the failure mode out loud, it's a strong experience signal: a single giant context re-renders every consumer on every change, and it accretes, everyone adds "just one more field" until it's a god object. The fixes: split contexts by concern, memoize values, or move server data out into the query cache, which usually shrinks the "global state" problem to almost nothing. (I've lived this: our app's main context provider grew until unrelated components re-rendered on any change; the durable fix was moving server data into the query cache and splitting what remained.)

Context vs Redux vs query cache

ToolRight jobWrong job (common misuse)
Local state90% of UI stateβ€”
ContextLow-frequency shared values: theme, auth session, feature flagsHigh-frequency data (re-render storms); server data
Redux/ZustandComplex multi-view UI state with devtools/undo needsBeing a hand-rolled server cache (loading flags, refetch logic in reducers)
Query cache (TanStack/SWR)All server data: caching, dedup, staleness, invalidationUI state (it's keyed to requests, not views)
Decision sentence

"Most apps that think they need Redux need a query cache. Once server data moves into a cache keyed by request, what's left of 'global state' is usually a theme, a session, and a couple of modals, context handles that fine."

Chapter 4: Data fetching & caching

The query cache model (what TanStack Query actually is)

Name the library once, then explain the model, the model is what's graded. A query cache is a client-side map from request identity β†’ {data, status, timestamp}:

Decision sentence

"I'd put a query-cache layer, TanStack Query in a React app, between components and the API client. It buys dedup, stale-while-revalidate, and targeted invalidation for free, all things teams otherwise hand-roll badly in context. I've led this migration in production; the mechanism I trust is the query key."

Paginate vs fetch-all: the decision that owns table questions

The textbook default is server-side pagination. Ask two questions before accepting it:

  1. How big is one user's dataset? Up to a few thousand rows / low MBs, the client can hold all of it.
  2. What does one page cost the backend? If serving page 2 requires computing the whole dataset anyway (any "sort by aggregated metric X" over a reporting service does, computing X for all N rows then discarding all but 50), pagination saves bytes, not compute, and compute is usually the expensive resource.
Server-side paginationFetch-once + derive in memory
Interaction latency (sort/search/page)Network-bound: RTT + backend work per click, hundreds of ms to secondsMemory-bound: ~ms, zero network per interaction
Backend loadOne query per interaction; expensive if each recomputes the datasetOne query per session (per staleTime window)
Client memory / initial payloadSmallWhole dataset, must be bounded
FreshnessPer interactionSnapshot; bounded by staleTime
Right whenUnbounded data, cheap page computation, fast-changing dataBounded per-user data, expensive backend aggregation, read-heavy exploration UI

With fetch-all, run interactions as a pure derivation chain over the cached dataset: search β†’ filter β†’ sort β†’ paginate, each step memoized. Sorting and paging become array operations; the UI updates within a frame.

When the dataset doesn't fit (hierarchies, huge accounts): don't force either extreme. Batch + progressive render: fetch in chunks (e.g. 10 parents at a time via parallel queries), render skeleton rows, fill as batches land. The user sees structure immediately and data streams in.

Decision sentence

"Server-side pagination optimizes bytes over the wire; if the bottleneck is backend compute per interaction, it's the wrong optimization. For a bounded per-user dataset I fetch once and derive search/sort/page in memory, interactions drop from seconds to milliseconds and backend load drops to one query per session. I apply it per-surface: where data was too big in my last migration, I batched with progressive rendering instead."

Mutations, optimistic updates, and races

Chapter 5: Lists at scale

Virtualization: fetching β‰  rendering

Fetch-all solves the network problem; the DOM problem remains. Ten thousand table rows is ~10K Γ— (cells Γ— nodes) DOM elements, layout and memory die long before the network does. Rule of thumb: past ~500-1,000 rows, virtualize: render only the ~30 rows in (and near) the viewport, absolutely positioned inside a container whose height equals rowCount Γ— rowHeight, and recycle row components as the user scrolls (react-window / TanStack Virtual).

Infinite scroll vs numbered pages

Chapter 6: Rendering & performance

CSR vs SSR vs hybrid

CSR (SPA)SSR / streamingHybrid (SSR shell + CSR data)
First paintSlow: HTML β†’ JS download/parse β†’ fetch β†’ renderFast: HTML arrives populatedFast shell, data hydrates in
SEO / link previewsPoor without prerenderingNativeGood
Interactivity modelEverything after load is instant-feelingServer render per navigation, or hydrate into SPASPA after first load
Infra costStatic hosting + CDNRender servers (or edge)Render servers
Right forAuthenticated tools, dashboards, anything behind loginPublic content: marketing, docs, e-commerce listingsPublic app-like products (feeds, search)

Decision drivers, in order: does SEO/first-visit speed on public pages matter (SSR side), or is it an authenticated tool where users pay the load once and interact for an hour (CSR side)? Most real answers are hybrid by route: marketing pages SSR'd/static, the app itself CSR. Two honest caveats worth volunteering: SSR doesn't fix a slow API, if the bottleneck is a downstream aggregation service, server-rendering just moves the wait; and hydration cost is real, you ship HTML plus the JS to make it alive.

Core Web Vitals: the vocabulary of the performance pass

Bundles & code splitting

The performance pass, verbatim

"Performance pass: first load is bounded by bundle and LCP, so route-splitting, CDN, and preloading the primary query. Interactions are bounded by INP, so in-memory derivations and virtualization for the table. Navigations should hit the warm query cache. And I'd instrument all three with RUM percentiles, p90 regressions hit the slowest users first, before I claim any of it works."

Chapter 7: Search & input patterns

Every design with a search box gets probed on the same three failure modes; volunteering them is cheap credibility.

Chapter 8: Real-time updates

PollingSSEWebSocket
DirectionClient pullsServer β†’ client streamBidirectional
Infra costNone (plain HTTP, cache-friendly)Long-lived connection, one per clientLong-lived + connection state, gateway boxes
LatencyUp to one interval~Instant~Instant
Right forDashboards, statuses, anything where 10-30s staleness is fineNotifications, progress bars, price tickers (server-push only)Chat, collab editing, anything the client streams up

Say the escalation rule: start with polling (with jitter, and a query cache makes it one line: refetchInterval), escalate to SSE when the interval you'd need drops below ~5s, and to WebSocket only when the client also needs to push. Most "real-time" dashboard requirements dissolve under the question "what's the actual freshness requirement?", reporting data computed daily does not need a socket. Also mention refetchOnWindowFocus: free freshness exactly when the user is looking, no infra at all.

Chapter 9: API design & data modeling

Reading a provided backend (the Plaid format)

When handed boilerplate endpoints, extract five things out loud, this five-minute read shapes your whole frontend design and is itself a graded skill:

  1. Entities and their relationships. "So we have accounts, each with many transactions, and transactions carry a category. That's my data model on the client too."
  2. Pagination style. Cursor or offset? Page size caps? This decides your list architecture (Ch 5) immediately.
  3. Error contract. Status codes only, or structured bodies ({code, message, retryable})? If unstructured, say you'll normalize in the API client so components render one error shape.
  4. Auth. Where does the token come from, where does it go, what happens on 401? (Sets up your security pass.)
  5. What's missing for your UI. The deliberate gaps are often the test: no batch endpoint (N+1 from the client), no aggregate/summary endpoint (client would compute sums over paginated data, wrong), no search param. Flag each: "I'd request a /summary endpoint here; computing this client-side over paginated data would be both wrong and slow."

Designing the API (when they ask you to shape it)

Data modeling in five sentences

Model the nouns as tables/collections with clear ownership: users, accounts (user_id), transactions (account_id, posted_at, amount_cents, category, status). Money is integer cents (never floats) plus a currency code. Status fields are enums with an explicit state machine (pending β†’ posted | failed), the frontend renders per-state, so ambiguity here becomes UI bugs. Index what you filter and sort by (account_id, posted_at composite for "recent transactions per account"). For flexible per-user blobs (view settings, preferences), a JSON column trades schema enforcement for velocity, enforce the shape at the API layer with a schema (OpenAPI/zod) instead, and promote a key to a real column when you need to index it.

REST vs GraphQL vs BFF

RESTGraphQLBFF (backend-for-frontend)
Fetch shapeFixed per endpoint; over/under-fetch at the edgesClient picks fields; one round trip for nested dataOne endpoint per screen need, shaped server-side
CachingHTTP-native (URLs are cache keys)Harder (POST body queries); needs client cache smartsHTTP-native
CostEndpoint sprawl for many screensSchema/resolver infra, query cost control, N+1 resolversYou own another service per client type
Right whenDefault; few clients, stable screensMany diverse clients (iOS/Android/web/partners) with different data needsOne frontend team wants screen-shaped APIs over microservices
Decision sentence

"REST by default, it's cacheable and boring. GraphQL earns its infra cost when many client types need different slices of the same graph. If the real problem is 'this screen needs five services,' I'd rather add a thin BFF than adopt GraphQL, same aggregation win, much less machinery. And a query cache on the client keeps the transport swappable later."

Chapter 10: Forms & multi-step flows

Multi-step flows (onboarding, a bank-linking wizard, checkout) are secretly state-machine questions. Design them as one:

Chapter 11: Security, fintech-grade

Run this as a named pass. Order: how code gets injected, how requests get forged, where tokens live, what data leaks.

Decision sentence

"My token model: access token in memory, refresh token in an httpOnly SameSite cookie, silent refresh on 401. XSS can't read what JS can't see, and SameSite closes the CSRF door that cookies open. localStorage tokens fail the first half of that sentence."

Chapter 12: CDNs, media, resumable uploads

CDN mechanics, the frontend half

Video delivery (the Netflix question, frontend view)

Resumable uploads

Any "user uploads something big" (video platform, document verification, statement upload) gets this design:

  1. Initiate: client asks the API to start an upload (POST /uploads with filename, size, content type); API returns an upload id and presigned URLs for object storage, so bytes go browser β†’ S3 directly, never through your app servers (they'd be a bandwidth bottleneck and add nothing).
  2. Chunk: client slices the file (File.slice), 5-10MB parts, uploads N parts in parallel (3-4 concurrent), tracks per-part completion. Progress UI is completed-bytes/total, cheap and honest.
  3. Resume: on failure/refresh, ask the server which parts it has (GET /uploads/:id), upload only the missing ones. This is the "resumable" part: state lives server-side, keyed by upload id, so even a browser crash loses nothing but the in-flight chunks. (tus is the open protocol name to drop.)
  4. Complete: client calls complete; server verifies parts (checksums), assembles, then kicks async processing (virus scan, transcode) via a queue, status polled or pushed to the client (processing β†’ ready), render per-state.
Decision sentence

"Big uploads go browser β†’ object storage with presigned URLs, chunked at 5-10MB with a few parts in parallel. Resume is just 'ask the server which parts it has.' App servers stay out of the byte path, they coordinate, storage carries."

Chapter 13: Rendering strategies & "why this framework"

Ch 6 gave you the CSR/SSR table. This chapter is the depth behind it, because "why this framework" is a named rubric line and the honest answer is always derived from the rendering requirement, never from taste. The trap is naming a framework first and reverse-engineering a reason.

The five points on the spectrum

They differ on exactly two questions. When is the HTML generated, and who generates it. Everything else follows.

StrategyHTML builtPer-user data?Right for
CSR (SPA)In the browser, after JS loadsYes, client-fetchedAuthenticated tools, dashboards, anything behind a login
SSG (static)At build time, onceNoDocs, marketing, blog. Pure CDN, no servers
ISR (incremental static)At build, then re-built on a timer or on demandNo, but content changesProduct catalogs, pricing pages, anything editorial with a CMS
SSRPer request, on a serverYesPublic pages that are personalized or need fresh SEO-visible data
Streaming SSRPer request, sent in chunks as readyYesSSR where one slow query would otherwise block the whole page

The decision rule to say out loud. Is the content public and crawlable? If no, SEO is off the table and CSR is legitimate. Is it the same for every user? If yes, SSG or ISR beats SSR because a CDN edge hit is cheaper and faster than any render server. Does first paint on a cold, slow connection drive money? If yes, you need server-generated HTML of some kind.

Hydration, and why it's the expensive part

This is the concept most candidates fumble, so be precise. SSR sends HTML that looks finished. It has no event listeners, so nothing works yet. Hydration is React re-running your component tree in the browser, comparing it against the server's DOM, and attaching listeners. So on an SSR page you ship the markup and the JS that would have produced that markup. You paid twice for one screen.

React Server Components and Suspense as a network boundary

The one-sentence version. RSC moves components to the server permanently, so they never ship JS to the browser at all, and they can read the database directly instead of going through an HTTP endpoint you designed.

Suspense is what makes streaming useful. A <Suspense fallback> boundary tells the server "flush everything above this now, send this region's HTML later when its data resolves." So Suspense boundaries are not a loading-spinner convenience, they are where you cut the page into delivery units. Draw them on the whiteboard. Shell and nav flush immediately, the slow transaction table streams in behind its own boundary. That single move turns a 900ms blocking SSR page into a 100ms shell.

The framework answer, derived

PickWhenThe cost you admit
Vite + React SPAAuthenticated dashboard. Every route is behind a login, SEO is irrelevant, users load once and stay an hour.Slow cold start, and you own routing/data-loading choices yourself.
Next.js (App Router)Mixed product. Public marketing and docs need SEO and fast first paint, the app itself is interactive. Route-level choice of SSG/ISR/SSR/streaming is the actual selling point.Render servers to run and pay for, a real learning curve on the server/client boundary, and framework lock-in.
Remix / React Router 7Form and mutation heavy, and you want progressive enhancement so the app degrades to working HTML forms without JS.Smaller ecosystem, and the web-standards-first model is unfamiliar to most teams.
AstroContent-dominant with islands of interactivity. Docs site with a live demo widget.Wrong tool the moment the product is mostly app.

Say the split explicitly, because "hybrid by route" is the answer that survives follow-ups. Marketing and docs static or ISR on a CDN. The authenticated app CSR, because SSR-ing per-user data you can't cache buys paint speed at the cost of a render server on the critical path. And name the honest caveat that SSR does not fix a slow API. If the bottleneck is a downstream aggregation service, server rendering just relocates the wait and now your render server is blocked too.

Decision sentence

"Rendering follows from crawlability and personalization. This product is behind auth, so SEO is off the table and I'd ship a Vite SPA with route-level code splitting. The marketing surface is a separate static build on the CDN. If we later needed a public, personalized, SEO-visible page, that's where I'd introduce streaming SSR with Suspense boundaries around the slow queries, and I'd accept render servers as the cost. What I wouldn't do is SSR an authenticated dashboard, that's paying for a render server and hydration to speed up a paint the user sees once per session."

Traps

"I'd use Next.js because it's the standard." That's the answer that loses the point. Derive it or pick something else. Claiming SSR improves interactivity. It improves paint and delays interactivity. Forgetting the render server is now a scaling and availability problem that a static CDN deploy never had.

Chapter 14: Browser security & auth flows, the deep pass

Ch 11 covers XSS, CSRF, token storage, and PII, which is the application layer. This chapter is the two layers you get pushed into after you answer that well. The browser's own policy mechanisms, and the OAuth flow that produced the token in the first place. Fintech rounds go here reliably.

Start from the same-origin policy

Everything below is a controlled exception to one rule. An origin is scheme + host + port, and by default code from one origin cannot read responses from another. https://app.plaid.com and https://api.plaid.com are different origins. So is http:// versus https:// on the same host.

The thing people get wrong. The browser sent the cross-origin request and the server did process it. CORS only controls whether your JS is allowed to read the response. That's why CORS is not a defense against CSRF, the damage is already done server-side before the response comes back.

CORS, including the preflight you'll be asked about

A simple request goes straight out. GET, HEAD, or POST, with only a short allowlist of headers, and a Content-Type of text/plain, multipart/form-data, or application/x-www-form-urlencoded. The browser sends it and then checks Access-Control-Allow-Origin before handing you the body.

Anything else triggers a preflight, which is a separate OPTIONS request asking permission first. The two things that trigger it in practice are Content-Type: application/json and a custom header like Authorization or X-Request-Id. Which means essentially every real API call preflights.

OPTIONS /v1/transactions            β†’ Origin: https://app.example.com
                                      Access-Control-Request-Method: POST
                                      Access-Control-Request-Headers: content-type, authorization

200                                 ← Access-Control-Allow-Origin: https://app.example.com
                                      Access-Control-Allow-Methods: POST, GET
                                      Access-Control-Allow-Headers: content-type, authorization
                                      Access-Control-Allow-Credentials: true
                                      Access-Control-Max-Age: 600      ← cache the preflight

CSP that actually works

Ch 11 says "add a CSP." Here's what a real one looks like and why the naive version is useless. An allowlist of domains is not a defense if any allowlisted domain hosts a JSONP endpoint or a bundler, which most CDNs do.

Content-Security-Policy:
  default-src 'self';
  script-src 'nonce-r4nd0m' 'strict-dynamic';   ← per-request nonce, not a domain list
  style-src 'self';
  img-src 'self' data: https://cdn.example.com;
  connect-src 'self' https://api.example.com;   ← where fetch/XHR/WS may go
  frame-ancestors 'none';                        ← who may iframe YOU (clickjacking)
  form-action 'self';                            ← where forms may POST
  object-src 'none'; base-uri 'none';
  report-uri /csp-violations                     ← ship Report-Only first

Cookie attributes, precisely

AttributeWhat it doesGet it wrong and
HttpOnlyJS cannot read it via document.cookieAn XSS payload exfiltrates the session
SecureOnly sent over HTTPSLeaks on any accidental plain-HTTP request
SameSite=LaxNot sent on cross-site subrequests, but sent on top-level navigationDefault-ish and safe. CSRF via forms and images is blocked
SameSite=StrictNever sent cross-site at allFollowing a link from email logs the user out, which is a UX regression, not a bug
SameSite=NoneSent everywhere. Requires SecureMandatory for third-party embeds, and the thing cookie deprecation is killing (Ch 17)
Path / DomainScope. Domain widens to subdomainsA compromised subdomain now reads your session cookie
__Host- prefixBrowser enforces Secure, no Domain, Path=/Cheap hardening most people don't know

The OAuth 2.0 flow, and why PKCE exists

You know "exchange a public token for an access token on the backend." That is the authorization-code pattern, so connect it to the standard vocabulary.

1. Browser β†’ /authorize?client_id&redirect_uri&state&code_challenge=S256(verifier)
2. User authenticates at the provider (your app never sees the password)
3. Provider redirects back β†’ /callback?code=abc&state=...
4. BACKEND β†’ POST /token  { code, client_secret, code_verifier }
5. Backend receives access_token (short TTL) + refresh_token
6. Backend sets an httpOnly session cookie. The browser never holds either token.

JWT versus opaque tokens, a common probe. A JWT is self-verifying, so any service can check the signature with no network call, which is what makes it scale. The cost is that you cannot revoke it. Once issued it's valid until expiry. So the real-world answer is short-lived access JWTs, roughly 5 to 15 minutes, plus a long-lived opaque refresh token that is revocable because it hits a database. Refresh rotation on top, meaning each refresh issues a new refresh token and invalidates the old one, so a replayed refresh token signals theft and you kill the whole family.

The rest of the pass, one line each

Decision sentence

"Auth is authorization-code with PKCE. The browser never holds a token, the backend does the exchange with the secret and sets an httpOnly, Secure, SameSite=Lax session cookie. Access tokens are 15-minute JWTs so services verify without a network hop, refresh tokens are opaque and rotating so they stay revocable. Then CSP with a per-request nonce as defense in depth for the XSS I didn't catch, and frame-ancestors so nobody can clickjack the transfer button."

Chapter 15: Webhooks & getting pushed data to the browser

Ch 8 gave you polling versus SSE versus WebSocket, which is the last mile to the browser. This chapter is the mile before it. In a fintech architecture the interesting data does not originate in your system, so the question "how does the UI know something changed" has two halves and most candidates only answer the second.

Why webhooks exist at all

Because you don't own the event. A bank posts a transaction on its own schedule. Your server has no way to know except to ask repeatedly, and polling a third party for every one of a million linked accounts is absurd, expensive, and still stale. So the provider calls you. A webhook is just an HTTP POST to a URL you registered, sent when something happened. That's the whole idea. The complexity is entirely in the failure modes.

Frame it as an inversion. Polling means you control timing and the cost scales with your poll rate times your user count. Webhooks mean the provider controls timing, the cost scales with actual event volume, and you inherit an endpoint that must be always-on, publicly reachable, and hostile-input-safe.

The full path, end to end

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   1. POST /webhooks/plaid       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ Provider β”‚ ──────────────────────────────► β”‚  Webhook      β”‚
  β”‚  (bank / β”‚      signed, at-least-once      β”‚  receiver     β”‚
  β”‚   Plaid) β”‚ ◄────────── 200 OK ──────────── β”‚  (thin!)      β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      within ~5s, always         β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                                                       β”‚ 2. verify sig
                                                       β”‚ 3. dedupe on event_id
                                                       β”‚ 4. enqueue + ACK
                                                       β–Ό
                                               β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                               β”‚  Queue (SQS / β”‚
                                               β”‚  Kafka)  ─────┼──► DLQ
                                               β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                                                       β”‚ 5. worker
                                                       β–Ό
                                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                        β”‚ Worker: FETCH from the   β”‚
                                        β”‚ provider API, write DB,  β”‚
                                        β”‚ emit internal event      β”‚
                                        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                   β”‚ 6. fanout
                                          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”
                                          β–Ό                 β–Ό
                                   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                   β”‚ Pub/Sub     β”‚   β”‚ Push / emailβ”‚
                                   β”‚ (Redis)     β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                   β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
                                          β”‚ 7. SSE to the right user's tabs
                                          β–Ό
                                   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                   β”‚  Browser:   β”‚  8. invalidate query
                                   β”‚  query cacheβ”‚     β†’ refetch β†’ re-render
                                   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Walk the whiteboard along that line and you've answered the question completely. The three boxes people forget are the queue, the dedupe check, and the DLQ.

Rule 1. The receiver is thin. ACK in milliseconds.

Verify the signature, dedupe, write the event to a queue, return 200. Nothing else. Do not update the database, do not call the provider's API, do not send an email, in the request handler.

The reason is a feedback loop worth naming. Providers time out webhook deliveries in a few seconds and retry on non-2xx. If your handler does real work it gets slow, slow means timeouts, timeouts mean retries, retries mean more load, which makes it slower. You brown out under exactly the traffic spike you most needed to survive. A thin receiver plus a queue turns an availability problem into a backlog you can drain.

Corollary. Return 200 even for events you don't care about, otherwise you're asking to be retried forever. Return a 4xx only for a genuinely malformed or unverifiable request, because that's a signal not to retry.

Rule 2. Verify the signature, on the raw bytes

Your endpoint is public. Anyone can POST to it claiming a transfer settled. Signature verification is the entire trust model.

The HMAC shape (Stripe, GitHub, and most providers). The sender computes HMAC-SHA256(secret, timestamp + "." + rawBody) and sends it as a header. You recompute and compare.

// Node, Express. NOTE: express.raw(), not express.json()
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const [ts, sig] = parseHeader(req.get('X-Signature'));

  // (a) replay window β€” reject anything older than ~5 minutes
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);

  // (b) recompute over the EXACT bytes received
  const expected = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(`${ts}.${req.body}`)          // req.body is a Buffer here
    .digest('hex');

  // (c) constant-time compare, never ===
  const ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  await queue.send(event);                 // then, and only then, enqueue
  res.sendStatus(200);
});

Rule 3. Assume at-least-once, out-of-order delivery

This is where your idempotency knowledge plugs in, and it's the highest-value part of the chapter.

Duplicates are guaranteed, not hypothetical. If your 200 is lost in transit, the provider retries an event it already delivered successfully. So every consumer must be safe to run twice.

-- the dedupe table. The unique constraint IS the mechanism.
CREATE TABLE processed_events (
  event_id     TEXT PRIMARY KEY,          -- provider's id
  received_at  TIMESTAMPTZ DEFAULT now()
);

-- in the worker, same transaction as the actual work:
BEGIN;
  INSERT INTO processed_events (event_id) VALUES ($1);   -- throws on duplicate
  ...do the real work...
COMMIT;                                    -- both land, or neither does

Doing the insert and the work in one transaction is the part that matters. Insert-then-crash-then-retry would otherwise skip the work forever, having recorded it as done.

Ordering is not guaranteed either, and this is where the frontend-relevant punchline lives.

The key idea

Treat the webhook as a signal, not as data. Don't apply the payload as a delta. Use it as "something about account X changed" and then fetch the current state from the provider's API. An out-of-order or duplicated signal is then harmless, because refetching current truth twice gives the same answer. Applying deltas out of order corrupts a balance permanently.

If you genuinely must apply the payload, you need a monotonic version or sequence per entity and you drop anything older than what you've stored. Say that as the fallback, but lead with signal-not-data. It's the answer that makes the whole system tolerant instead of fragile.

Rule 4. Retries, DLQ, and the reconciliation backstop

Rule 5. Getting it to the browser without a stampede

Now you're back in Ch 8 territory, but with a specific constraint. The worker knows an event happened, and it needs to reach only the tabs belonging to that one user.

  1. Worker publishes to Redis pub/sub on a per-user channel, user:1234:events. Never broadcast to all connections and filter client-side, that leaks other users' activity into a channel their browser can see.
  2. SSE endpoint per connected client subscribes to that user's channel. SSE is right here because the flow is server-to-client only. It's plain HTTP, it reconnects automatically, and it gives you Last-Event-ID so a client can resume from where it dropped.
  3. The message is tiny and carries no data. { type: 'transactions.updated', accountId: 'acc_1' }. Same signal-not-data principle, one layer up. It avoids duplicating auth checks in the push path, because the refetch goes through your normal authorized endpoint.
  4. The client invalidates rather than patches. queryClient.invalidateQueries(['transactions', accountId]). The query cache then refetches only what's actually mounted on screen, which is the whole reason the cache layer earns its place.

The scaling problem to raise unprompted. A bank syncs and fires 500 webhooks for one user in two seconds. Naively that's 500 SSE messages and 500 refetches, and you've DDoSed yourself from the inside. Two fixes, and naming either wins the point. Coalesce server-side, debouncing per user so you emit at most one "changed" signal per few seconds. Or coalesce client-side, since a query cache already dedupes concurrent invalidations of the same key. Do both. Also add refetchOnWindowFocus, which gets you free freshness precisely when someone is looking.

Testing and operating it

Decision sentence

"Third-party data arrives by webhook, not polling, because the provider owns the timing. My receiver is deliberately thin. Verify the signature over the raw bytes with a constant-time compare and a timestamp window, dedupe on the provider's event id, enqueue, return 200 in single-digit milliseconds. A worker then treats the event as a signal and fetches current state from the API rather than applying the payload as a delta, which makes duplicates and out-of-order delivery harmless. Then it publishes a tiny notification to that user's Redis channel, SSE carries it to their tabs, and the browser invalidates the affected query key so only mounted views refetch. Coalesced per user, because a bank sync can fire hundreds of events in seconds. And a nightly reconciliation job repairs anything the webhook path lost, because it will lose things."

Traps

Doing the work in the handler. The retry feedback loop above. Hashing the parsed body. Every signature fails and you'll never guess why. Trusting the payload without verifying. Your endpoint is public. Assuming exactly-once, in-order delivery. Neither is true of any real provider. Having no reconciliation path, which means silent permanent data loss the first time your endpoint 500s for an hour.

Chapter 16: HTTP caching, the layer under the query cache

You know TanStack Query (Ch 4). That's an in-memory, per-tab, application-level cache that dies on refresh. Underneath it sit three more caches you don't control but do configure, and interviewers probe this because it separates people who've shipped from people who've read.

The four caches, from closest to furthest

CacheLivesSurvives reload?Configured by
Query cache (TanStack)JS heap, one tabNostaleTime, gcTime
Service Worker cacheDisk, per originYes, and works offlineYour own JS
HTTP cache (browser)Disk / memory, per originYesResponse headers
CDN / shared cacheEdge POPs, all usersYesResponse headers + purge API

The framing to say. The query cache decides whether to make a request. The HTTP cache decides whether that request touches the network. They're complementary, and a well-configured pair means a warm navigation costs zero bytes.

Freshness versus validation, the core distinction

Every caching header answers one of two questions, and keeping them separate is most of the battle.

So a 304 is not a cache hit in the sense that matters for latency. It saves bandwidth, not time. On a 200ms RTT mobile connection, a 304 is still 200ms. People conflate these constantly, and drawing the distinction is a cheap way to look precise.

The directives, and what each is actually for

Cache-Control: max-age=300                 fresh for 5 min in ANY cache
Cache-Control: private, max-age=60         browser only. CDNs must not store it
Cache-Control: public, max-age=31536000, immutable
                                           fingerprinted assets. never revalidate
Cache-Control: no-cache                    store it, but ALWAYS validate first
Cache-Control: no-store                    never write to disk at all
Cache-Control: max-age=60, stale-while-revalidate=600
                                           serve stale instantly, refresh behind it
Cache-Control: max-age=0, must-revalidate  no stale serving, ever, even offline
Vary: Accept-Encoding, Authorization       cache key includes these headers
ETag: "a1b2c3"                             opaque version id for validation
Age: 240                                   how long a shared cache has held it

The strategy per resource type

ResourceHeadersWhy
HTML shell / index.htmlno-cache (or short max-age)It contains the hashed asset URLs. Cache it and users are pinned to an old deploy forever
Hashed JS / CSSpublic, max-age=31536000, immutableFilename changes on change, so it can never be wrong
Authenticated JSONprivate, no-store, or private, max-age=0, must-revalidate + ETagNever in a shared cache. ETag still saves bandwidth on polling
Public reference data (categories, currencies)public, max-age=3600, stale-while-revalidate=86400Same for everyone, changes rarely. Should be a CDN hit
User avatars / uploadspublic, max-age=31536000, immutable on a content-addressed URLChange the URL on change, not the bytes at a URL

The deploy interaction is worth volunteering. The reason the HTML shell must not be cached is that a stale shell references chunk filenames that no longer exist on the CDN, so a user mid-session hits a 404 on a lazy-loaded route. Two mitigations. Keep the previous build's chunks around for a release or two rather than purging on deploy. And detect a chunk-load error in the app, then prompt "a new version is available, reload." That's a concrete production war story and it lands well.

ETags, and how they interact with concurrency

GET /accounts/1        β†’ 200  ETag: "v7"   { balance: 4210 }
GET /accounts/1        β†’ If-None-Match: "v7"
                       ← 304 Not Modified   (no body, saves the payload)

The second use is the one that impresses. The same ETag gives you optimistic concurrency control on writes.

PATCH /accounts/1      β†’ If-Match: "v7"    { nickname: "Rent" }
                       ← 412 Precondition Failed   if someone else wrote v8 first

That is the HTTP-native answer to the lost-update problem, and it pairs directly with your optimistic-update UI (Ch 4). A 412 means "your view was stale," so you refetch, show the conflict, and let the user decide. It also composes with idempotency keys, which handle the duplicate-submit problem, whereas If-Match handles the concurrent-edit problem. They solve different failures and knowing which is which is a genuinely senior distinction.

Strong versus weak ETags. W/"v7" means semantically equivalent but not byte-identical, which is what you get after gzip or minor serialization changes. Strong ETags are required for range requests and for If-Match to be meaningful.

The shared-cache layer, briefly

Decision sentence

"Caching is layered and I'd configure each layer for what it's good at. Hashed bundles get a year with immutable on the CDN, the HTML shell gets no-cache so a deploy actually reaches people. Authenticated JSON is private so it can never enter a shared cache, with ETags so polling costs a 304 instead of a payload. Public reference data goes public with stale-while-revalidate, which is the CDN version of what my query cache does in memory. And the same ETag doubles as optimistic concurrency control on writes with If-Match, so a concurrent edit returns 412 instead of silently overwriting."

Chapter 17: Cross-origin embedding & third-party SDKs

Ch 19 walks the Link-shaped widget end to end. This chapter is the platform mechanics underneath it, because "how does an SDK you ship run inside a page you don't control" is the single most Plaid-specific architecture question there is. It's also the area where the browser is actively changing under everyone's feet, which makes it good material for the "under uncertainty" part of the rubric.

The problem statement

A merchant puts four lines of your script on their checkout page. A user then types their bank credentials into something that appears inside the merchant's page. Two hard requirements fall out immediately.

  1. The host page must never be able to read those credentials. Not by reading the DOM, not by patching fetch, not by keylogging the input.
  2. The host page must not be able to break your UI, and your UI must not break theirs. Their global CSS reset, their jQuery, their z-index: 999999 header.

Both requirements point at the same answer, and the reasoning is the answer.

Why an iframe, and not injected DOM

The naive approach is a script that injects a modal into the host's DOM. State the two reasons that fails.

A cross-origin iframe is a separate origin, and therefore a separate everything. Separate DOM the host cannot query, separate JS context they cannot patch, separate CSS, separate storage. The same-origin policy does the work for you. That's the sentence to say.

So the SDK splits into two pieces, and drawing this split is most of the whiteboard answer.

  MERCHANT PAGE (merchant.com)          YOUR ORIGIN (cdn.you.com)
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ <script src="cdn.you.com/  β”‚
  β”‚            link.js">       β”‚        thin loader, ~10KB:
  β”‚                            β”‚        - creates the iframe
  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚        - postMessage bridge
  β”‚  β”‚ iframe               │◄─┼─────── - public API (open/exit)
  β”‚  β”‚  src=cdn.you.com/... β”‚  β”‚        - NO credential logic
  β”‚  β”‚                      β”‚  β”‚
  β”‚  β”‚  YOUR full app.      β”‚  β”‚        the real app, your origin:
  β”‚  β”‚  Host cannot read    β”‚  β”‚        - bank credential UI
  β”‚  β”‚  the DOM or the JS   β”‚  β”‚        - talks to YOUR api directly
  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚        - host page sees nothing
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Why the loader must be tiny. You're on someone else's critical rendering path. Load it async, keep it in the tens of KB, and never block their page. The heavy app only downloads when the user actually opens the flow, inside the iframe, where its cost is yours and not theirs.

postMessage, done correctly

The iframe and the host need to talk. The host says "open," the iframe says "the user finished, here's a public token." postMessage is the only channel across origins, and it's trivially easy to do insecurely.

// SENDING β€” always name the exact target origin, never '*'
iframe.contentWindow.postMessage({ type: 'link.open', config }, 'https://cdn.you.com');

// RECEIVING β€” validate in this order, and bail early
window.addEventListener('message', (e) => {
  if (e.origin !== 'https://cdn.you.com') return;      // 1. WHO sent it
  if (e.source !== iframe.contentWindow) return;       // 2. which frame exactly
  const msg = e.data;
  if (!msg || typeof msg.type !== 'string') return;    // 3. shape it like hostile input
  if (!ALLOWED_TYPES.has(msg.type)) return;            // 4. allowlist, not denylist
  handle(msg);
});

Hardening the frame both ways

The live problem, third-party storage partitioning

This is the part that makes you sound current, and it's genuinely unsettled, which suits the "clear under uncertainty" criterion.

What changed. Browsers have been shutting down cross-site tracking, and embedded SDKs are collateral damage. Safari's ITP and Firefox's ETP block third-party cookies outright, and Chrome has been partitioning storage. The practical effect is that your iframe's cookies and localStorage are keyed by the pair (your origin, the top-level site), not by your origin alone. So a user who authenticated in your iframe on merchant-a.com arrives on merchant-b.com as a total stranger, and even a returning visit can lose state.

What you do about it, in order of preference.

  1. Design for statelessness. Don't depend on cross-site persistence at all. Each session starts from a short-lived token the merchant's backend created. This is the answer that ages well and it's the one to lead with.
  2. Pass state explicitly through the bridge or the iframe URL rather than relying on ambient cookies. Signed, short-TTL, single-use.
  3. The Storage Access API. document.requestStorageAccess() lets a frame ask for unpartitioned access, but it generally requires a user gesture and prior first-party interaction, and behavior differs across browsers. Name it, and name the caveat.
  4. CHIPS, meaning Set-Cookie: ...; SameSite=None; Secure; Partitioned, is the sanctioned way to keep a per-top-level-site cookie. It fixes "remember state on this merchant" and explicitly does not fix "recognize the user across merchants," which is the correct privacy outcome.
  5. Popup instead of iframe as the fallback for flows that truly need first-party context, since a popup is a top-level context on your own origin with real first-party storage. The cost is popup blockers and a worse mobile experience, so it's a fallback, not the default.

Then the honest closing note. Do not build anything that depends on cross-site identity, because that's the capability the platform is deliberately removing and any workaround has a shelf life.

What the SDK's public API should look like

You'll likely be asked to sketch it. Apply Ch 2's props-as-API thinking to a third-party surface, where the constraint is that you can never make a breaking change.

const handler = YourSDK.create({
  token: 'link-sandbox-abc',        // created server-side, short TTL, single use
  onSuccess: (publicToken, meta) => {/* send to THEIR backend to exchange */},
  onExit:    (err, meta) => {},     // user bailed, or a real error
  onEvent:   (name, meta) => {},    // analytics hook, fire-and-forget
});
handler.open();
handler.destroy();                  // MUST exist: remove listeners + iframe
Decision sentence

"The SDK is a thin async loader on the merchant's page plus a cross-origin iframe holding the real app. The iframe is the security boundary, and the same-origin policy is what enforces it, so the host page cannot read the credentials the user types or patch my network layer, and their CSS can't reach my UI. The two sides talk over postMessage with an explicit target origin and an origin check plus a message-type allowlist on receive, and the only thing that ever crosses into the untrusted page is a single-use public token their backend exchanges server-side. I'd assume no third-party storage, because it's being partitioned away, so each session is bootstrapped from a short-lived server-created token rather than a cookie. CHIPS or the Storage Access API are the fallbacks if we need per-merchant persistence, and I'd deliberately avoid depending on cross-merchant identity because the platform is removing it."

Traps

Injecting a modal into the host DOM and calling it isolated. postMessage(msg, '*'), or receiving without an origin check. Sending a real access token across the bridge instead of a single-use public token. Assuming third-party cookies work. No destroy(), so the widget leaks in every host SPA. A heavy synchronous loader that tanks the merchant's LCP, which is how you get uninstalled.

Chapter 18: Worked example, transactions dashboard from a given API

Prompt shape: "Here's our backend. Design a web dashboard where a user views their accounts and transactions, with search, filters, and category summaries."

Given boilerplate:
GET /accounts                          β†’ [{id, name, mask, balance_cents, currency}]
GET /accounts/:id/transactions         β†’ {items: [{id, posted_at, amount_cents,
    ?after=cursor&limit=100               merchant, category, status}], next_cursor}
PATCH /transactions/:id                β†’ update category
Auth: Bearer token. Errors: {code, message, request_id}

Step 1: Requirements (say the numbers)

Clarify: rows per user? Say the interviewer answers "a few thousand transactions, a handful of accounts." Freshness? Transactions post hourly-ish, not real-time. Devices: desktop web primarily. Core flows: scan recent activity, find a specific transaction, recategorize, see spend by category. That scale answer just decided the architecture, note it out loud.

Step 2: Read the API aloud, flag gaps

Step 3: Boxes and arrows

App shell (auth, routing, error boundary)
└─ DashboardPage                      ── owns: URL state (account, filters, page)
   β”œβ”€ AccountSwitcher                 ← useAccounts()          [cache: ['accounts']]
   β”œβ”€ SummaryCards (by category)      ← derived, no fetch
   β”œβ”€ FilterBar (search, date, cat.)  β†’ writes URL params
   └─ TransactionsTable
      β”œβ”€ Row Γ— ~50 (virtualized if needed)
      └─ Pagination (client-side)
Data layer: useTransactions(accountId) [cache: ['txns', accountId]]
            β†’ drains cursor pages until next_cursor = null (parallel-ish, sequential cursors)
            useUpdateCategory()        β†’ PATCH + targeted cache update
API client: auth header, 401 refresh-and-replay, error normalization

Step 4: Data flow deep dive (the 2/3 you're graded on)

Step 5: The three passes

Chapter 19: Worked example, embeddable widget (Link-shaped)

Prompt shape: "Design an embeddable widget that third-party sites drop into their page so users can complete a sensitive multi-step flow (e.g. connect a bank account)." This is the home-turf question for a fintech interview, it composes Ch 10 (flows) + Ch 11 (security) + component API design (Ch 2) at the product's core.

The architecture decision: how does third-party embedding work?

OptionMechanicsVerdict
NPM component in their appRuns in the host page's JS contextRejected for the sensitive core: host page (and its XSS) can read every keystroke, including credentials
Full redirect to our domainLeave host site, come back with a codeSecure but kills conversion; acceptable fallback (and needed for some bank OAuth flows anyway)
Iframe on our origin + thin SDK (chosen)Host includes small script; script injects an iframe served from our domain; postMessage bridgeCredentials typed inside our origin, the browser's same-origin policy walls the host page out. The host page never sees the data, by construction

Boxes and arrows

Host page (untrusted)
β”œβ”€ SDK script (~10KB): create({token, onSuccess, onExit}) β†’ open()
β”‚     injects β†’ <iframe src="https://widget.ours.com?session=...">
β”‚     bridge  ← postMessage (origin-checked both ways)
└─ receives only: success(public_token) / exit(error) / events

Inside iframe (our origin) β€” the real app:
  FlowStateMachine: select-institution β†’ credentials β†’ mfa? β†’ select-accounts β†’ success
  per-step components, flow context, our API client
  β†’ our API: session-scoped short-lived token, never exposed to host

The SDK's component API (Ch 2 applied)

Keep the host-facing surface tiny and evolvable: a constructor taking a server-minted short-lived session token (the host's backend creates it, so our API never trusts the browser), two callbacks (onSuccess(public_token), onExit(error?)), and an optional onEvent for analytics. The success payload is a one-time public token the host exchanges server-side for real credentials/access, so nothing durable ever transits the browser bridge. Version the SDK independently of the iframe app: the iframe deploys continuously (it's just our web app), the SDK is a stable, boring shim, this split is what makes "fix a bug for all customers without them redeploying" possible.

Deep-dive points interviewers pull on

Chapter 20: Worked example, Netflix-style browse + resumable upload

Prompt shape: "Design the Netflix home/browse experience," sometimes with "and how do creators upload videos?" bolted on. Frontend-leaning version spends most time on the browse surface and the player handoff; Ch 12 carries the media mechanics.

Browse surface, boxes and arrows

BrowsePage
β”œβ”€ HeroBillboard (preloaded, LCP element)
└─ Row Γ— ~20 (category shelves)        ← virtualized vertically
   └─ TitleCard Γ— ~50 per row          ← virtualized horizontally, lazy images
Data: GET /browse β†’ shelf metadata + first N cards per shelf (one aggregated call)
      GET /shelf/:id/titles?after=…    β†’ horizontal infinite scroll per shelf
Cache: ['browse'] staleTime ~10min; personalization makes it per-user (no CDN for JSON)
Player route: code-split; manifest + first segments prefetched on card hover/focus

Chapter 21: Self-quiz

Interviewer hands you three endpoints and a dashboard mock. What are the first five things you extract from the API?
Entities/relationships, pagination style (cursor vs offset), error contract shape, auth mechanics, and what's missing for the UI (batch/summary/search endpoints). Flag gaps aloud and say whether you'd request an endpoint or work around it client-side.
When is fetch-all + in-memory derivation better than server-side pagination, and what kills it?
Bounded per-user dataset (≀ a few thousand rows) and expensive backend computation per page (serving page 2 costs the same as the whole dataset). Then one fetch per session, interactions at memory speed. Killed by: unbounded data, tight freshness needs, or low-memory clients β†’ cursor pagination + server-side search/sort + virtualization, per-surface.
Where do auth tokens live and why?
Access token (~15min) in memory; refresh token in httpOnly Secure SameSite cookie; API client refreshes on 401 and replays. localStorage fails because any XSS reads it; httpOnly cookies reopen CSRF, which SameSite (+ CSRF tokens for cross-site needs) closes. The two halves are one answer.
User types "ca" then "cat"; the "ca" response lands last. What happened and what are the fixes?
Stale-response race. Fixes: AbortController cancels the superseded request; or sequence-tag and drop stale responses; or key a query cache by term so each term is its own entry and stale never overwrites active. Plus debounce 200-300ms so fewer requests race at all.
Why is a 10K-row table slow even after you've fixed all the fetching?
DOM, not network: 10K rows Γ— dozens of nodes blows layout and memory; 16ms frame budget dies. Virtualize: render the ~30 viewport rows in a fixed-height scroll container, recycle on scroll. Costs to name: native Ctrl+F breaks, aria-rowcount for screen readers, height estimation for variable rows.
Optimistic update: when yes, when no?
Yes: high-frequency, high-success, low-stakes, easily reversible (toggles, category edits, likes) β€” apply to cache, roll back + toast on failure. No: money movement, anything with server-side validation you can't mirror, anything hard to un-show. Default remains pessimistic with disabled-while-pending.
Deploy just went out. Why do users get the new version instantly without cache-busting hacks?
Assets are content-fingerprinted and cached immutable/forever; the HTML referencing them is no-cache. New deploy β†’ new HTML β†’ new asset URLs. CDN caches never need invalidation because content changes change the key.
Sketch the resumable upload flow in four steps.
Initiate (POST /uploads β†’ upload id + presigned part URLs); chunk (5-10MB parts, 3-4 parallel, browser β†’ object storage directly); resume (GET upload state β†’ send only missing parts); complete (server verifies + assembles β†’ async processing via queue β†’ client renders status per-state).
Why an iframe (not an NPM component) for a sensitive embedded widget?
Same-origin policy: inside an iframe on our origin, the host page β€” and any XSS it carries β€” cannot read the DOM where credentials are typed. NPM components run in the host's context and inherit its compromises. Bridge via origin-checked postMessage; host receives only a one-time public token exchanged server-side.
The interviewer 10x's your data scale mid-answer. What's the senior move?
Say "that changes my answer," and re-derive visibly: fetch-all β†’ cursor pagination, client search β†’ server search param, add virtualization, summaries β†’ dedicated endpoint. Offer it per-surface (small accounts keep the fast path). Updating quickly under new constraints is the graded behavior, not defending the old design.